Repository navigation
Conversation
When a TypeError, ReferenceError or RangeError raised in the CLI's own files ends or fails a command, send one Sentry envelope to Checkly's error ingest. Errors in user code, dependencies, failed checks, API and network errors are not reported. Errors are noted where they are thrown (BaseCommand.catch), printed (CommandStyle.longError and the sites that print only err.message) or emitted by the check runner, and sent from BaseCommand.finally, so commands that print their errors and exit are covered too. Wrapped errors are found through their cause chain. The report carries no paths outside the CLI, credentials, arguments, environment, host name or private package names. Only release builds report: the release workflow writes the DSN from the CHECKLY_CLI_ERROR_DSN repository variable. CHECKLY_NO_ERROR_REPORTING=1 or DO_NOT_TRACK=1 turns it off.
…report error-reporting.ts did four jobs in one file. It is now a folder: classify.ts decides what is a CLI bug, scrub.ts removes what names the user, event.ts builds the Sentry event and report.ts holds the run's state and sends. isCliBug now reads the stack with Sentry's Node parser, the same one the event uses, instead of its own regex, so classification and the reported in_app frames cannot disagree. Frames of code run through eval or new Function are traced to the file that ran it. Because the parser is loaded only when needed, noteCliError keeps a few candidate errors and the check runs when the command ends.
sorccu
left a comment
There was a problem hiding this comment.
Thanks, this is careful work. I've marked which findings were reproduced; the rest come from reading the code.
On the open question: let's drop @sentry/core. #1591 is stacked on this branch and replaces it with a local module of about 120 lines: stack parser, DSN → envelope URL, envelope serialization and crypto.randomUUID(). For the same input it produces the same frames and envelope as @sentry/core 11, apart from a few small differences the PR lists. Merge it into this branch to pick it up.
The size matters beyond the CLI install. A project's package.json usually lists checkly, so the bundled package.json of a Playwright Check Suite does too (bundle.packages.prune is opt-in). Our runners would install and cache @sentry/* (~14 MB) for those bundles, though the CLI never runs there.
Major
-
Errors thrown by built-in methods are never reported (
classify.ts,throwingFile). When a built-in throws, V8's innermost frame is<anonymous>, e.g.at String.repeat (<anonymous>).throwingFilereturns that,isOwnPath('<anonymous>', …)is false, and the error is classified as not the CLI's. This was reproduced with@sentry/core11 on Node 24 for'x'.repeat(-1),[].reduce(fn),new Date('nope').toISOString()and[1].map(undefined).JSON.stringifyon a circular object behaves the same way. Suggested fix: skip frames whose filename is not an absolute path orfile://URL (<anonymous>,native,index N) as well asnode:frames, so the first real frame decides. Add specs for errors thrown by built-ins. -
Errors while processing test results are not reported (
abstract-check-runner.ts,configureResultListener).this.queue.add(() => this.processMessage(...))is never awaited or caught, and the reporters'onCheckStart/onCheckEndrun insideprocessMessage. ATypeErrorthere becomes an unhandled rejection, and Node ends the process beforerunner.run()'scatchorBaseCommand.finallyruns. Nothing is noted or sent. A synchronous throw in the'message'handler becomes an uncaught exception, with the same result. This is probably where most real bugs intest/trigger/pw-testwould occur: reporters and result formatting. The description lists unhandled rejections as a follow-up, but I'd handle this case in this PR. Catch the queued promise's rejection, callnoteCliError, and fail the run so the error goes throughrun()'scatchand the awaitedfinally. -
Nothing tells users about the new outbound request. Only the README covers it. There is nothing in the docs site and no release note. Users with egress allowlists or data-handling reviews look there, not at the GitHub README. Please add a short docs page covering error reporting and the two opt-out variables, and mention it in the release notes of the version that ships it.
Do reports get sent before the process exits?
On the paths the PR hooks into, yes. oclif's Command._run awaits this.catch() and then this.finally() before the error reaches handle(), which prints and calls process.exit. BaseCommand.finally awaits reportNotedCliError, so the POST completes, or times out after 3 s, before the exit. This holds for errors that are thrown, this.exit()/this.error(), --json mode, and commands that only set process.exitCode. Nothing in src calls process.exit directly, apart from the hand-off to the project's own CLI. The paths that do lose the report are item 2 above and crashes outside a command's run: oclif config loading, hooks, and bin/launch.cjs.
Minor
- Scrubbing:
replaceDir's lookahead does not accept.,!,]or}. A bare cwd before sentence punctuation (no config in /work/acme-proj.) is left in place, and the home pass misses it when the project is outside home (e.g.cwd=/app/<name>,HOME=/root).- The URL-credential regex keeps part of a password that contains
@(http://bob:p@ss@proxy→http://<redacted>@ss@proxy), andkey=valueredaction stops at a space inside quotes (password: "hunter two"→"<redacted> two"). Rather than patching the regexes, rebuild credential-bearing URLs from scheme and host, and redact quoted values up to the closing quote. - Absolute paths outside cwd and home are sent as they are, e.g. a CI checkout under
/builds/<org>/<repo>run from a subdirectory withHOME=/root, or--configpointing elsewhere. Consider replacing every absolute path outside the package root.
- No test covers the production wiring. No test covers
BaseCommand.catch/finally,longErroror the runner noting errors that then get sent. Dropping one of thenoteCliErrorcalls, or no longer awaiting the report, would keep every spec green. The DSN can only come fromPACKAGE_ROOT/error-reporting.json, so such a test can't be written yet. A small test-only override would allow one. - Nothing checks that the packed tarball contains
error-reporting.json. A packaging mistake would silently turn reporting off. Onetar -tzfcheck in the release job would catch it. reportCliErroris exported and documented as an entry point, but only the spec calls it.- Stale paths: the comments in
release.ymland.gitignorestill namesrc/services/error-reporting.ts, which is now a folder. test-sessions/list.tsnow callsnoteCliErrorforparseUnixTimestampfailures. If that ever throws aTypeError/RangeError, a user's bad--fromvalue would be reported as a CLI bug.- The wait before the error is printed is up to 3 s. That only happens on a real bug, but 1–1.5 s would be plenty for a single POST.
- Two conventions for printed errors:
longError(title, err)notes automatically, butlongError(title, err.message)andthis.error(err.message)need a manualnoteCliError(err). Passing the error object everywhere would mean nobody has to remember the manual call.
🤖 Generated with Claude Code
Affected Components
Notes for the Reviewer
The CLI had a
// TODO: we can add Sentry hereinBaseCommand.catch. This PR reports crashes caused by bugs in the CLI's own code to Checkly's own error tracking (the RUM ingest atrum.checklyhq.com, which speaks the Sentry envelope protocol). Nothing goes to sentry.io.What is reported. Only a
TypeError,ReferenceErrororRangeErrorwhose innermost stack frame is in the CLI's own files (not itsnode_modules), at most one per run. Errors in user code, dependencies, failed checks, API and network errors, and Node'sERR_INVALID_URLfor a bad user URL are not. Wrapped errors (new Error('…', { cause }), e.g. while loading a check file) are found through the cause chain, and only the inner error is sent. A user's invalid configuration that makes a construct crash is reported on purpose: bad config should produce a clear message, not a crash.Where it hooks in. There is no single place that sees every crash:
deploy,test,triggerand many other branches print the error and exit without rethrowing. So errors are noted where they are thrown (BaseCommand.catch), printed (CommandStyle.longError, plus the 7 sites that print onlyerr.message) or emitted byAbstractCheckRunner, and sent fromBaseCommand.finally, which oclif runs on every path before its handler prints and callsprocess.exit.What a report contains. Error type, scrubbed message and stack; CLI version, command id, CLI mode; OS, arch, runtime version. Scrubbing: frames outside the CLI become
<project file>; packages the CLI does not depend on directly become<dependency>; paths under cwd/home become<project file>/<home file>(segment boundaries, forward slashes,file://URLs, case-insensitive on Windows);cu_keys, Bearer/Basic tokens, URL userinfo,*token=/*secret=/*api_key=-style values and the values ofCHECKLY_API_KEY/CHECKLY_ACCOUNT_ID. No server name, user, argv or environment.Release only. The DSN is not in the repo. The release workflow writes it from the repository variable
CHECKLY_CLI_ERROR_DSNtopackages/cli/error-reporting.json(gitignored, infiles) for prerelease and release builds. Canary and local builds (0.0.0*,0.0.1-dev) never report. Until the variable is set, nothing is reported, so this is safe to merge first. The key is write-only and can be revoked in the ingest, as with every CLI that ships a crash-report key (Wrangler, Shopify, flyctl).Opt-out.
CHECKLY_NO_ERROR_REPORTING=1orDO_NOT_TRACK=1. Documented in the README ("Error reporting") andCLAUDE.md.Cost to the user.
@sentry/coreis loaded with dynamicimport()only when a report is sent. The report waits at most 3 s (timeoutplusAbortSignal.timeout) and never throws, so exit codes are unchanged. For an error that ends the command throughcatch, the report is sent before oclif prints the error, because oclif exits right after printing; this delay only happens on a real CLI bug.Not covered (follow-ups). Crashes in un-awaited callbacks and unhandled rejections (needs a
process.on('unhandledRejection')handler, which changes how the CLI exits).Code layout.
src/services/error-reporting/:classify.ts(is it a CLI bug; reads stacks with Sentry's Node parser, the one the event uses, and tracesevalframes to the file that ran them),scrub.ts(removes what names the user),event.ts(builds the event),report.ts(run state, DSN, sending).Testing. 24 unit tests in
error-reporting/__tests__/error-reporting.spec.tsusing a real local HTTP server and real.mjsfiles in an install directory with a space in its name (file-URL stacks with%20, as on Windows). Each new test fails with its bug restored. Full suite: 239 files, 4586 passed / 7 skipped; lint,tsc,test:typesclean. Manually: the packed tarball installed undermy installwith a bug injected indist/rest/checks.js→checks listexits 1, prints the error, sends one envelope; withDO_NOT_TRACK=1none; a thrown bug inwhoamialso sends one. The envelope was parsed by the ingest's own Gosentry.ParseEnvelope, and axios's User-Agent reachesrum.checklyhq.comthrough Cloudflare (401 invalid sentry_keywith a fake key). Windows is only covered by CI.Open question for Simo: keep
@sentry/coreor replace it? It adds 14.2 MB to every install (@sentry/core5.4 MB +@sentry/conventions8.8 MB). Todaycheckly@10.1.0installs 55 MB in 211 packages, so +26 %. We use onlyuuid4, a stack-line parser, DSN parsing and envelope serialization, which is about 40 lines withcrypto.randomUUID.New Dependency Submission
@sentry/core@^11.10.0(supports Node >=22.12, published 2026-10-28; one dependency,@sentry/conventions; no OpenTelemetry). It builds and serializes Sentry envelopes and parses Node stack lines, so the events match what the ingest's Sentry parser expects.@sentry/nodewas not used: it would auto-instrument the process and pull in OpenTelemetry. Whether to keep it at all is the open question above.